<!DOCTYPE html> <!-- 指定文档类型为 HTML5 -->
<html lang="en"> <!-- HTML 根元素，语言为英文（不影响中文内容显示） -->
<head> <!-- 文档头部，包含元信息与样式 -->
    <meta charset="UTF-8"> <!-- 指定页面编码为 UTF-8，支持中文显示 -->
    <title>语音听写</title> <!-- 浏览器标签页标题 -->
    <style> /* 页面内联样式定义 */
        .container{ /* 左右两列容器的通用样式 */
        width:48%;float:left /* 宽度占48%，左浮动用于两列布局 */

        }

     @media (max-width:500px){ /* 响应式：在小屏幕下容器宽度设为100% */
           .container{
        width:100%;float:left /* 独占一行的布局 */

        }
     }
     .buttons1{ /* 左侧按钮样式 */
     margin-right:10%;margin-top:20px;font-size: 20px;padding: 6px 10px;cursor: pointer;background-color: white;color: blue;
        border: 1px solid #555555;border-radius: 5px;float:left;}
     .buttons2{ /* 右侧按钮样式 */
     margin-right:10%;margin-top:20px;font-size: 20px;padding: 6px 10px;cursor: pointer;background-color: white;color: blue;
        border: 1px solid #555555;border-radius: 5px;float:right;}


    </style>
</head> <!-- 头部结束 -->

<body> <!-- 文档主体开始 -->

<header> <!-- 页面头部横幅区域 -->
    <div> <!-- 包裹标题与说明文字的容器 -->
        <h1 style="text-align:center"> <!-- 居中主标题 -->
            <img alt="tx" src="{{ url_for('static', path='images/tingxie.png') }}" width="120" height="120"> <!-- 标题左侧图标，来自静态资源 -->
            语音听写能力演示 <!-- 主标题文本 -->
        </h1>
        <p style="text-align:center"> <!-- 居中副说明文字段落 -->
            语音听写是把即时语音(≤60秒)转换成对应的文字信息，支持实时返回识别结果，达到一边上传音频一边获得识别文本的效果。
            让机器能够“听懂”人类语言，相当于给机器安装上“耳朵”，使其具备“能听”的功能。
        </p>
    </div> <!-- 头部容器结束 -->
</header> <!-- 头部结束 -->
<br/> <!-- 空行，增加垂直间距 -->
<br/> <!-- 空行，增加垂直间距 -->
<br/> <!-- 空行，增加垂直间距 -->

<div class="container"> <!-- 左侧文件上传容器 -->

    <!--上传文件--> <!-- 文件上传表单模块开始 -->
    <form action="/" method="POST" id="upload" enctype="multipart/form-data"> <!-- 提交到 /，使用 multipart 传文件 -->
        <h2>请选择需要语音听写的mp3音频文件</h2> <!-- 左侧模块标题 -->
        <br/> <!-- 间距 -->
        <input type="file" name="file" id="pic" accept=".mp3" class="buttons1" required> <!-- 仅允许选择 mp3 文件 -->

        <input type="submit" value="开始识别" onclick="uploadPic()" class="buttons2" > <!-- 提交按钮，触发 uploadPic（注：示例 AJAX 不匹配后端） -->
        <span class="showUrl"></span> <!-- 用于显示返回 URL 的占位（原示例保留） -->
        <img src="" class="showPic" alt=""> <!-- 用于显示返回图片的占位（原示例保留） -->
    </form> <!-- 文件上传表单结束 -->

    <div style="clear:both;"></div> <!-- 清除浮动，避免布局错乱 -->

</div> <!-- 上传容器结束 -->

<div class="container"> <!-- 右侧麦克风录音容器 -->
    <h2>或使用麦克风进行语音听写</h2> <!-- 右侧模块标题 -->
    <button id="micBtn" type="button" class="buttons1">🎤 开始录音</button> <!-- 控制开始/停止录音的按钮 -->
    <span id="micStatus" style="margin-left:10px;font-size:16px;color:#555;">麦克风未启动</span> <!-- 状态提示文案 -->
    <div style="clear:both;"></div> <!-- 清除浮动 -->

</div> <!-- 麦克风容器结束 -->

<div style="width:48%;float:left"><h2>识别结果</h2> <!-- 结果展示区域左列 -->

    <div style="border:1px solid #ddd;min-height: 400px;padding: 8px;"> <!-- 结果内容容器，固定最小高度 -->
        {% if result %} <!-- Jinja2 模板条件：如果有后端渲染结果 -->
        <p style="font-size: 20px ">{{result}}</p> <!-- 展示上传文件的识别结果文本 -->
        {% endif %}
        <p id="liveResult" style="font-size: 20px "></p> <!-- 展示麦克风录音识别的结果文本 -->
    </div> <!-- 结果容器结束 -->

    <div style="clear:both;"></div> <!-- 清除浮动 -->

</div> <!-- 结果列结束 -->

<!--上传文件的JavaScript--> <!-- 以下脚本为原有示例，提交到 /get_account（与后端不匹配，仅保留示例） -->
<script type="text/javascript"> <!-- 第一个脚本块：上传文件的示例 -->
      function uploadPic() {
        var form = document.getElementById('upload'), /* 获取表单元素 */
          formData = new FormData(form); /* 构造表单数据（包含文件） */
         $.ajax({ /* jQuery AJAX 示例（与后端路由不匹配，仅保留） */
            url:"/get_account", /* 示例目标地址：后端未实现 */
            type:"post", /* POST 提交 */
            data:formData, /* 传入表单数据 */
            processData:false, /* 禁止自动处理数据，让浏览器原样发送 */
            contentType:false, /* 由浏览器自动设置 multipart/form-data */
            success:function(res){ /* 成功回调（示例） */
             if(res){
              alert("上传成功！"); /* 弹窗告知 */
             }
             console.log(res); /* 控制台输出响应 */
             $("#pic").val(""); /* 清空文件选择框 */
             $(".showUrl").html(res); /* 显示返回结果到占位元素 */
             $(".showPic").attr("src",res); /* 显示图片（示例） */
            },
            error:function(err){ /* 失败回调 */
             alert("网络连接失败,稍后重试",err); /* 弹窗提示错误 */
            }

         }) /* AJAX 调用结束 */
      }


</script> <!-- 第一个脚本块结束 -->

<script type="text/javascript"> <!-- 第二个脚本块：麦克风录音与上传到 /mic -->
      // 麦克风录音：采集音频并编码为16kHz单声道WAV，上传到 /mic
      (function(){
        let isRecording = false; // 是否处于录音中状态
        let audioContext = null; // WebAudio 上下文对象
        let source = null; // 媒体流音源
        let processor = null; // ScriptProcessorNode，用于获取 PCM
        let stream = null; // 媒体流对象
        let pcmData = []; // 存放每次回调的 Float32 PCM 数据片段

        const micBtn = document.getElementById('micBtn'); // 录音按钮元素
        const micStatus = document.getElementById('micStatus'); // 状态文字元素
        const liveResult = document.getElementById('liveResult'); // 结果展示元素

        micBtn && micBtn.addEventListener('click', async function(){ // 点击按钮切换录音/停止
          if(!isRecording){
            // 开始录音
            liveResult.textContent = ''; // 清空上次结果
            try{
              stream = await navigator.mediaDevices.getUserMedia({audio:true}); // 请求麦克风权限并获取音频流
            }catch(e){
              alert('无法访问麦克风：' + e); // 提示无法获取麦克风
              return;
            }
            audioContext = new (window.AudioContext || window.webkitAudioContext)(); // 创建音频上下文
            source = audioContext.createMediaStreamSource(stream); // 将媒体流作为音源
            processor = audioContext.createScriptProcessor(4096, 1, 1); // 设置处理器缓冲大小与通道数
            pcmData = []; // 重置缓存数组
            processor.onaudioprocess = function(e){ // 音频处理回调，每次拉取一段 PCM
              const channelData = e.inputBuffer.getChannelData(0); // 获取单声道数据
              pcmData.push(new Float32Array(channelData)); // 缓存当前片段
            };
            source.connect(processor); // 音源接入处理器
            processor.connect(audioContext.destination); // 处理器接到输出（保持处理回调触发）
            isRecording = true; // 切换为录音状态
            if(micBtn) micBtn.textContent = '⏹ 停止录音'; // 更新按钮文案
            if(micStatus) micStatus.textContent = '录音中… 点击停止录音'; // 更新状态文案
          }else{
            // 停止录音并上传
            isRecording = false; // 切换为非录音状态
            if(micBtn) micBtn.textContent = '🎤 开始录音'; // 恢复按钮文案
            if(micStatus) micStatus.textContent = '处理中…'; // 提示正在处理
            try{
              if(processor) processor.disconnect(); // 断开处理器
              if(source) source.disconnect(); // 断开音源
              if(audioContext) audioContext.close(); // 关闭音频上下文
              if(stream){
                stream.getTracks().forEach(t=>t.stop()); // 停止所有媒体轨道
              }
            }catch(e){/* 忽略 */} // 忽略清理异常

            // 合并 Float32 数据  // 将多个片段拼接为一段连续数据
            let totalLength = pcmData.reduce((sum, arr)=>sum + arr.length, 0); // 计算总长度
            let merged = new Float32Array(totalLength); // 分配合并后的数组
            let offset = 0; // 合并偏移量
            for(const arr of pcmData){ // 遍历片段进行复制
              merged.set(arr, offset); // 拷贝片段到目标数组
              offset += arr.length; // 移动偏移量
            }

            // 下采样到 16kHz  // 以平均法重采样，简化实现
            const inSampleRate = (audioContext && audioContext.sampleRate) ? audioContext.sampleRate : 48000; // 输入采样率
            const outSampleRate = 16000; // 目标采样率
            function downsampleBuffer(buffer, sampleRate, outRate){ // 简单重采样函数
              if(outRate === sampleRate){ // 若采样率一致直接返回
                return buffer;
              }
              const ratio = sampleRate / outRate; // 采样率比值
              const newLen = Math.round(buffer.length / ratio); // 目标长度
              const result = new Float32Array(newLen); // 目标数组
              let offsetResult = 0; // 目标偏移
              let offsetBuffer = 0; // 源偏移
              while(offsetResult < result.length){ // 逐块计算平均值
                const nextOffsetBuffer = Math.round((offsetResult + 1) * ratio); // 下一个边界
                let accum = 0, count = 0; // 累加与计数
                for(let i=offsetBuffer; i<nextOffsetBuffer && i<buffer.length; i++){
                  accum += buffer[i]; // 累加样本
                  count++; // 计数
                }
                result[offsetResult] = accum / (count || 1); // 写入平均值
                offsetResult++; // 前进目标偏移
                offsetBuffer = nextOffsetBuffer; // 前进源偏移
              }
              return result; // 返回重采样结果
            }

            const downsampled = downsampleBuffer(merged, inSampleRate, outSampleRate); // 执行下采样

            // 转为 16 位 PCM 并编码为 WAV  // 生成 WAV 文件字节视图
            function floatTo16BitPCM(input){ // 将 Float32Array 转为 16 位整型 PCM
              const output = new DataView(new ArrayBuffer(input.length * 2)); // 分配字节视图
              let offset = 0; // 写入偏移
              for(let i=0;i<input.length;i++, offset+=2){ // 遍历样本
                let s = Math.max(-1, Math.min(1, input[i])); // 截断到 [-1,1]
                output.setInt16(offset, s < 0 ? s * 0x8000 : s * 0x7FFF, true); // 按小端写入 16 位整数
              }
              return output; // 返回字节视图
            }

            function encodeWAV(samples, sampleRate){ // 将 PCM 数据打包成 WAV 文件格式
              const buffer = new ArrayBuffer(44 + samples.length * 2); // 分配头 + 数据的字节缓冲
              const view = new DataView(buffer); // 创建字节视图

              function writeString(view, offset, string){ // 向视图写入字符串
                for(let i=0;i<string.length;i++){
                  view.setUint8(offset + i, string.charCodeAt(i)); // 写入单字节字符
                }
              }
              const channels = 1; // 单声道
              const bitsPerSample = 16; // 16 位采样
              const byteRate = sampleRate * channels * bitsPerSample / 8; // 每秒字节数
              const blockAlign = channels * bitsPerSample / 8; // 每采样块字节数

              writeString(view, 0, 'RIFF'); // 写入 RIFF 标识
              view.setUint32(4, 36 + samples.length * 2, true); // 文件总大小（不含前8字节）
              writeString(view, 8, 'WAVE'); // 写入 WAVE 标识
              writeString(view, 12, 'fmt '); // 写入 fmt 子块标识
              view.setUint32(16, 16, true); // fmt 子块大小（16）
              view.setUint16(20, 1, true); // 音频格式（PCM=1）
              view.setUint16(22, channels, true); // 声道数
              view.setUint32(24, sampleRate, true); // 采样率
              view.setUint32(28, byteRate, true); // 字节率
              view.setUint16(32, blockAlign, true); // 对齐单位
              view.setUint16(34, bitsPerSample, true); // 位深
              writeString(view, 36, 'data'); // 写入 data 子块标识
              view.setUint32(40, samples.length * 2, true); // 数据区大小

              const PCM = floatTo16BitPCM(samples); // 获取 16 位 PCM 视图
              let offsetData = 44; // 数据区起始偏移
              for(let i=0;i<PCM.byteLength;i++){ // 将 PCM 字节复制到视图
                view.setUint8(offsetData + i, PCM.getUint8(i)); // 写入字节
              }
              return view; // 返回完整 WAV 视图
            }

            const wavView = encodeWAV(downsampled, outSampleRate); // 编码生成 WAV 视图
            const audioBlob = new Blob([wavView], {type:'audio/wav'}); // 创建 Blob 对象用于上传

            const formData = new FormData(); // 构造表单数据
            formData.append('file', audioBlob, 'recording.wav'); // 附加音频文件字段
            try{
              const resp = await fetch('/mic', {method:'POST', body: formData}); // 发送到后端 /mic 接口
              if(!resp.ok){ throw new Error('上传失败: ' + resp.status); } // 检查响应码
              const data = await resp.json(); // 解析 JSON 响应
              if(micStatus) micStatus.textContent = '识别完成'; // 更新状态
              if(liveResult) liveResult.textContent = (data && data.result) ? data.result : ''; // 显示识别结果
            }catch(e){
              if(micStatus) micStatus.textContent = '上传或识别失败'; // 失败状态
              alert(e); // 弹窗显示错误
            }
          }
        }); // 事件监听结束
      })(); // 立即执行函数结束

</script> <!-- 第二个脚本块结束 -->

</body> <!-- 文档主体结束 -->
</html> <!-- HTML 文档结束 -->